<!--
  ~ Copyright 2022 Singularity Data
  ~
  ~ Licensed under the Apache License, Version 2.0 (the "License");
  ~ you may not use this file except in compliance with the License.
  ~ You may obtain a copy of the License at
  ~
  ~ http://www.apache.org/licenses/LICENSE-2.0
  ~
  ~ Unless required by applicable law or agreed to in writing, software
  ~ distributed under the License is distributed on an "AS IS" BASIS,
  ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  ~ See the License for the specific language governing permissions and
  ~ limitations under the License.
  ~
-->
<!doctype html>
<html>

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <!-- TODO: directly using tailwindcss CDN is not recommended, should switch to other frameworks -->
  <script src="https://cdn.tailwindcss.com"></script>
  <style type="text/tailwindcss">
    @layer components {
      .btn-primary {
        @apply py-2 px-4 bg-sky-500 text-white font-semibold rounded-lg shadow-md hover:bg-sky-700 focus:outline-none focus:ring-2 focus:ring-sky-400 focus:ring-opacity-75
      }
      .btn-nav {
        @apply text-black py-2 px-2 my-1 font-semibold text-left rounded-lg hover:bg-sky-100 focus:outline-none focus:ring-2 focus:ring-sky-400 focus:ring-opacity-75
      }
      .btn-selected {
        @apply bg-sky-600 text-white hover:bg-sky-600
      }
    }
  </style>
  <title>RisingWave Dashboard</title>
</head>

<body>
  <div class="flex flex-row h-screen w-screen">
    <div class="p-3 flex flex-col flex-none bg-slate-50" style="width: 18rem">
      <div class="flex flex-col px-2">
        <h2 class="text-sm leading-6 font-semibold">
          RisingWave
        </h2>
        <h1 class="text-3xl font-bold">
          Dashboard
        </h1>
      </div>
      <div class="my-3 flex flex-col">
        <button class="btn-nav btn-selected">
          Cluster
        </button>
        <button class="btn-nav">
          Streaming
        </button>
        <button class="btn-nav">
          About
        </button>
      </div>
      <div class="my-2 px-2">
        <p class="text-gray-500 text-xs">😇 Note: this navbar is just a demo.</p>
      </div>
    </div>
    <div class="grow py-3 px-5 flex flex-col h-screen overflow-y-scroll">
      <h1 class="mb-2 text-sm leading-6 font-semibold text-sky-500 dark:text-sky-400">
        Cluster Information
      </h1>
      <div id="clusters" class="grid gap-2 md:grid-cols-2 lg:grid-cols-3 w-full">
      </div>

      <div class="flex mt-5 mb-2 text-sm">
        <h1 class="flex-auto leading-6 font-semibold text-sky-500 dark:text-sky-400">
          Stream Actors
        </h1>
        <div class="form-check">
          <input class="form-check-input" type="checkbox" value="" id="mvOnMvCheckbox"
            onchange="handleMvOnMvClick(this);">
          <label class="form-check-label text-gray-500" for="mvOnMvCheckbox">
            Resolve MV on MV
          </label>
        </div>
      </div>
      <div id="actors" class="w-full">
      </div>
      <div class="flex mt-5 mb-2 text-sm">
        <h1 class="flex-auto leading-6 font-semibold text-sky-500 dark:text-sky-400">
          Materialized Views
        </h1>
        <div class="form-check">
          <input class="form-check-input" type="checkbox" value="" id="showAssociateMvCheckbox"
            onchange="handleShowAssociateMvClick(this);">
          <label class="form-check-label text-gray-500" for="showAssociateMvCheckbox">
            Show Associated MV
          </label>
        </div>
      </div>
      <div class="form-check">
        <select class="flex mb-2 leading-4 font-semibold text-sky-500 dark:text-sky-200" id="mvSelect"
          onchange="handleMvSelectClick(this);">
        </select>
      </div>
      <div id="mvFragments" class="w-full">
      </div>
    </div>
  </div>
</body>

<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"
  integrity="sha512-894YE6QWD5I59HgZOGReFYm4dnWc1Qt5NtvYSaNcOP+u1T9qYdvdihz0PPSiiqn/+/3e7Jo4EaG7TubfWGUrMQ=="
  crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.2.1/d3.min.js"
  integrity="sha512-wkduu4oQG74ySorPiSRStC0Zl8rQfjr/Ty6dMvYTmjZw6RS5bferdx8TR7ynxeh79ySEp/benIFFisKofMjPbg=="
  crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.21/lodash.min.js"
  integrity="sha512-WFN04846sdKMIP5LKNphMaWzU7YpMyCU245etK3g/2ARYbPK9Ub18eG+ljU96qKRCWh+quCY7yefSmlkQw1ANQ=="
  crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="https://cdn.jsdelivr.net/npm/d3-flextree@2.1.2/build/d3-flextree.min.js"
  integrity="sha256-ZjkehPx5n2M0daimR+hR4q8q4wIcPUNUrUTsykjK56Y=" crossorigin="anonymous"></script>

<script>
  /// Whether we should resolve mv on mv
  let resolveMvOnMv = false
  let currentMv = ""
  let currentMvs = new Set
  let showAssociateMv = false

  const types = new Set(["dynamicFilter", "source", "sink", "project", "projectSet", "filter", "materialize", "localSimpleAgg", "globalSimpleAgg", "hashAgg", "appendOnlyTopN", "hashJoin", "topN", "hopWindow", "merge", "exchange", "chain", "batchPlan", "lookup", "arrange", "lookupUnion", "union", "deltaIndexJoin", "expand"]);

  const cluster = (type, cluster) => `
<div class="p-6 max-w bg-white rounded-xl shadow-md flex flex-col space-y-1">
  <div class="flex flex-row items-center">
    <div class="w-3 h-3 flex-none bg-green-600 rounded-full mr-2"></div>
    <div class="text-xl font-medium text-black">${type}</div>
  </div>
  <div class="flex flex-col">
    <p class="text-gray-500 m-0">Running</p>
    <p class="text-gray-500 m-0">${cluster.host.host}:${cluster.host.port}</p>
  </div>
</div>`


  const actors = (actors, nodeId) => `
<div class="p-6 max-w bg-white rounded-xl shadow-md flex flex-col space-y-1">
  <div id="actor-wrapper-${nodeId}" class="border border-gray-200 overflow-x-scroll">
    <svg id="actor-${nodeId}" class="h-full"></svg>
  </div>
  <div id="message-${nodeId}" class="w-full flex flex-row"></div>
</div>`

  const mvActors = (actors, mvId) => `
<div class="p-6 max-w bg-white rounded-xl shadow-md flex flex-col space-y-1">
  <div class="flex flex-row items-center">
    <div class="w-3 h-3 flex-none bg-green-600 rounded-full mr-2"></div>
    <div class="text-xl font-medium text-black">#ID: ${mvId}</div>
  </div>
  <div id="actor-wrapper-${mvId}" class="border border-gray-200 overflow-x-scroll">
    <svg id="actor-${mvId}" class="h-full"></svg>
  </div>
  <div id="message-${mvId}" class="w-full flex flex-row"></div>
</div>`

  const dispatcherNode = (node) => (({ hashMapping, ...o }) => o)(node)

  /// Remove `input` from node object
  const exprNode = (actorNode) => (({ input, ...o }) => o)(actorNode)

  /// Transform a single actor to a d3.hierarchy
  const hierarchyActor = (actor) => {
    /// Find a key ends with `Node`

    const findTitle = (actorNode) => Object.keys(actorNode).find(x => types.has(x))

    /// Transform an actor node to a struct that can be parsed by d3.hierarchy
    const hierarchyActorNode = (node) => {
      return {
        name: findTitle(node),
        children: (node.input || []).map(hierarchyActorNode),
        operatorId: node.operatorId
      }
    }

    return d3.hierarchy({
      name: `${_.toLower(actor.dispatcher.type)}Dispatcher`,
      children: [hierarchyActorNode(actor.nodes)],
      operatorId: "dispatcher"
    })
  }

  /// Move all elements in a node to (0, 0) and return { width, height }.
  const boundBox = (root, { margin: { top, bottom, left, right } }) => {
    let x0 = Infinity
    let x1 = -x0
    let y0 = Infinity
    let y1 = -y0

    root.each(d => x1 = d.x > x1 ? d.x : x1)
    root.each(d => x0 = d.x < x0 ? d.x : x0)
    root.each(d => y1 = d.y > y1 ? d.y : y1)
    root.each(d => y0 = d.y < y0 ? d.y : y0)

    x0 -= left
    x1 += right
    y0 -= top
    y1 += bottom

    root.each(d => d.x = d.x - x0)
    root.each(d => d.y = d.y - y0)

    return { width: x1 - x0, height: y1 - y0 }
  }

  // Create a tree layout with root at the right.
  //
  // This function is used for layouting a single actor.
  const treeLayoutFlip = (root, { nodeRadius, dx, dy }) => {
    const tree = d3.tree().nodeSize([dy, dx])

    // Flip x, y
    root = tree(root)

    // Flip back x, y
    root.each(d => [d.x, d.y] = [d.y, d.x])

    // LTR -> RTL
    root.each(d => d.x = -d.x)

    return root
  }

  const nodeRadius = 12
  const nodeMarginX = nodeRadius * 6
  const nodeMarginY = nodeRadius * 4
  const actorMarginX = nodeRadius * 2
  const actorMarginY = nodeRadius * 2

  /// Layout a single actor, return { width, height, g }
  const layoutActor = (hierarchy, color, handleEvent) => {
    const root = treeLayoutFlip(hierarchy, { nodeRadius, dx: nodeMarginX, dy: nodeMarginY })
    const treeLink = d3.linkHorizontal().x(d => d.x).y(d => d.y)

    let { width, height } = boundBox(root, {
      margin: {
        left: nodeRadius * 4 + actorMarginX,
        right: nodeRadius * 4 + actorMarginX,
        top: nodeRadius * 3 + actorMarginY,
        bottom: nodeRadius * 4 + actorMarginY
      }
    })

    const g = d3.create("svg:g")

    g.append("rect")
      .attr("width", width - actorMarginX * 2)
      .attr("height", height - actorMarginY * 2)
      .attr("x", actorMarginX)
      .attr("y", actorMarginY)
      .attr("fill", "white")
      .attr("stroke-width", 1)
      .attr("stroke", "#555")

    const link = g.append("g")
      .attr("fill", "none")
      .attr("stroke", "#555")
      .attr("stroke-opacity", 0.4)
      .attr("stroke-width", 1.5)
      .selectAll("path")
      .data(root.links())
      .join("path")
      .attr("d", treeLink)

    const node = g.append("g")
      .attr("stroke-linejoin", "round")
      .attr("stroke-width", 3)
      .selectAll("g.rw-dashboard-actor-node")
      .data(root.descendants())
      .join("g")
      .attr("transform", d => `translate(${d.x},${d.y})`)
      .attr("class", "rw-dashboard-actor-node")

    node.append("circle")
      .attr("fill", color)
      .attr("r", nodeRadius)
      .style("cursor", "pointer")
      .on("click", handleEvent)

    node.append("text")
      .attr("fill", "black")
      .text(d => d.data.name)
      .attr("font-family", "sans-serif")
      .attr("text-anchor", "middle")
      .attr("dy", nodeRadius * 1.8)
      .attr("fill", "black")
      .attr("font-size", 12)
      .attr("transform", "rotate(-8)")

    return { width, height, g }
  }

  const fragmentIdOf = (actor) => actor.fragmentId || 0

  /// Create a d3.hierarchy of actors
  const hierarchyActors = (actors, metaFn) => {
    /// Mapping fragmentId -> { [actorId], [parent] }
    const actorNodes = new Map
    /// actorId -> Actor
    const actorMap = new Map

    actors.forEach(actor => actorMap.set(actor.actorId, actor))

    actors.forEach(actor => {
      const actorId = actor.actorId
      const fragmentId = fragmentIdOf(actor)

      if (!actorNodes.has(fragmentId)) {
        let lastFragmentId = null;
        (actor.downstreamActorId || []).forEach(downstreamActorId => {
          const downstreamFragmentId = fragmentIdOf(actorMap.get(downstreamActorId))
          // sanity check: downstream actors should belong the the same fragment
          if (lastFragmentId != null && downstreamFragmentId != lastFragmentId) {
            console.error("failed to construct graph: downstream actors should belong the the same fragment")
          }
          lastFragmentId = downstreamFragmentId
        })
        const parents = lastFragmentId ? [lastFragmentId] : []
        actorNodes.set(fragmentId, { actors: [actorId], parents })
      } else {
        actorNodes.get(fragmentId).actors.push(actorId)
      }
    })

    /// Resolve MV on MV
    if (resolveMvOnMv) {
      actors.forEach(actor => {
        const fragmentId = fragmentIdOf(actor)

        if (actor.nodes.chain) {
          let upstreamActorIds = actor.nodes.input[0].merge.upstreamActorId
          if (upstreamActorIds.length != 0) {
            let upstreamFragmentId = fragmentIdOf(actorMap.get(upstreamActorIds[0]))
            actorNodes.get(upstreamFragmentId).parents.push(fragmentId)
          }
        }
      })
    }

    /// Reverse mapping
    const actorReverseMapping = new Map

    /// Get reverse mapping
    actorNodes.forEach(({ actors, parents }, fragmentId) => {
      for (const parent of parents) {
        const list = (actorReverseMapping.get(parent) || [])
        list.push(fragmentId)
        actorReverseMapping.set(parent, list)
      }
    })

    const roots = []

    /// Split into multiple graphs
    actorNodes.forEach(({ actors, parents }, fragmentId) => {
      const buildGraph = (root, nextColor) => (
        {
          id: root,
          children: (actorReverseMapping.get(root) || []).map(child => buildGraph(child, nextColor)),
          actorIds: actorNodes.get(root).actors,
          ...metaFn(actorMap.get(actorNodes.get(root).actors[0]), { color: nextColor() })
        })

      const countGraph = (root) => _.sum((actorReverseMapping.get(root) || []).map(countGraph)) + 1

      if (parents.length == 0) {
        const totalActors = countGraph(fragmentId)
        let countedActor = 0
        const interp = d3.interpolateRainbow
        const nextColor = () => {
          countedActor += 1
          return interp(countedActor / totalActors)
        }

        const graph = buildGraph(fragmentId, nextColor)
        roots.push(graph)
      }
    })

    return roots
  }

  // Create a flextree layout with root at the right.
  //
  // This function is used for layouting a single actor.
  const flextreeLayoutFlip = (root) => {
    const flextree = d3.flextree()

    // Flip width, height
    root.each(d => [d.size[0], d.size[1]] = [d.size[1], d.size[0]])

    // Do layout
    root = flextree(root)

    // Flip back x, y and width, height
    root.each(d => [d.x, d.y] = [d.y, d.x])

    // Left-to-right -> Right-to-left
    // root.each(d => d.x = -d.x)

    // Flip back width, height in case that users may use it
    root.each(d => [d.size[0], d.size[1]] = [d.size[1], d.size[0]])

    return root
  }

  const layoutStreamGraphs = (actors, nodeId) => {
    const svg = d3.select(`#actor-${nodeId}`)

    const oneColumn = ([actorId, selectedActor, node]) => {
      const actorJump = () =>
        `<a target="_blank" rel="noopener noreferrer" class="text-sky-600" href="http://localhost:16680/search?service=compute&tags=%7B%22actor_id%22%3A%22${actorId}%22%2C%22msg%22%3A%22chunk%22%7D">Trace Message of Actor #${actorId}</a><br>
         <a target="_blank" rel="noopener noreferrer" class="text-sky-600" href="http://localhost:16680/search?service=compute&tags=%7B%22actor_id%22%3A%22${actorId}%22%2C%22epoch%22%3A%22-1%22%7D">Trace Epoch "-1" of Actor #${actorId}</a><br>`
      return `<div class="flex-1 overflow-x-scroll border border-gray-200 p-1">
        <p class="text-xs font-mono">@${selectedActor.node.host.host}:${selectedActor.node.host.port}</p>
        <p>${actorJump()}</p>
        <p class="whitespace-pre text-xs font-mono">${JSON.stringify(node, null, 2)}</p>
      </div>`
    }
    const showInfo = (actorIds, selectedActors, nodes) => {
      $(`#message-${nodeId}`).html(_.zip(actorIds, selectedActors, nodes).map(oneColumn).join('\n'))
    }

    const findInfo = (actorIds, actorNodes, operatorId, action) => {
      if (actorNodes[0].operatorId == operatorId) {
        action(actorIds, actorNodes.map(node => exprNode(node)))
      } else {
        (actorNodes[0].input || []).forEach((input, idx) => {
          findInfo(actorIds, actorNodes.map(node => node.input[idx]), operatorId, action)
        })
      }
    }

    const handleEvent = (d, actor) => {
      const fragmentId = fragmentIdOf(actor)
      // TODO: use a HashMap instead of probing all actors
      const selectedActors = actors.actors.filter(actor => fragmentIdOf(actor) == fragmentId)
      const actorIds = selectedActors.map(actor => actor.actorId)
      if (d.data.operatorId == "dispatcher") {
        const nodes = selectedActors.map(actor => ({ dispatcher: dispatcherNode(actor.dispatcher), downstreamActorId: actor.downstreamActorId }))
        showInfo(actorIds, selectedActors, nodes)
      } else {
        findInfo(actorIds, selectedActors.map(actor => actor.nodes), d.data.operatorId, (actorIds, nodes) => showInfo(actorIds, selectedActors, nodes))
      }
    }

    // TODO: some actors will be created, but as we only render one actor for one fragment, they won't be actually used.
    let hierarchyRoots = hierarchyActors(actors.actors, (actor, { color }) => {
      const { width, height, g } = layoutActor(hierarchyActor(actor), color, (e, d) => handleEvent(d, actor))
      return {
        size: [width, height],
        g
      }
    })

    const layoutStreamGraph = (tree) => {
      const root = flextreeLayoutFlip(d3.flextree().hierarchy(tree))

      const extents = root.extents

      const width = extents.right

      // To flip a rectangle, we need to know its width
      const flipX = (x, w) => -extents.left + width - x - w

      const gg = d3.create("svg:g")

      const g = gg.append("g")
        .attr("transform", d => `translate(0,${-extents.top})`)

      const flextreeLink = d3.linkHorizontal()
        .x(d => flipX(d.x + d.xSize / 2, 0))
        .y(d => d.y + d.ySize / 2)

      const link = g.append("g")
        .attr("fill", "none")
        .attr("stroke", "#555")
        .attr("stroke-opacity", 0.4)
        .attr("stroke-width", 4)
        .selectAll("path.rw-dashboard-actor-link")
        .data(root.links())
        .join("path")
        .attr("class", "rw-dashboard-actor-link")
        .attr("d", flextreeLink)

      const actors = g.append("g")
        .attr("stroke-linejoin", "round")
        .attr("stroke-width", 3)
        .selectAll("g.rw-dashboard-actor")
        .data(root.descendants())
        .join("g")
        .attr("class", "rw-dashboard-actor")
        .attr("transform", d => `translate(${flipX(d.x, d.xSize)},${d.y})`)

      actors.append((d) => d.data.g.node())

      actors.append("text")
        .attr("fill", "black")
        .text(d => "#" + d.data.id + ", Actor " + _.sortBy((d.data.actorIds)).join(", "))
        .attr("font-family", "sans-serif")
        .attr("text-anchor", "end")
        .attr("dy", d => d.ySize - actorMarginY + nodeRadius)
        .attr("dx", d => d.xSize - actorMarginX)
        .attr("fill", "black")
        .attr("font-size", 14)

      return { width: extents.right - extents.left, height: extents.bottom - extents.top, g: gg }
    }

    let gWidth = 0
    let gHeight = 0

    const graphs = hierarchyRoots.map(root => layoutStreamGraph(root))
    graphs.forEach(({ height }) => gHeight = Math.max(gHeight, height))
    graphs.forEach(({ width, height, g }) => {
      svg.append(() => g.attr("transform", `translate(${gWidth}, ${(gHeight - height) / 2})`).node())
      gWidth += width + 50
    })

    svg.attr("viewBox", [0, 0, gWidth, gHeight].join(" "))

    // Resize the wrapper of svg to make all nodes visible and large enough. `/ 32` is by experience.
    $(`#actor-wrapper-${nodeId}`).css("height", `${gHeight / 16}rem`)
  }

  fetch('/api/clusters/1')
    .then(response => response.json())
    .then(data => data.forEach(
      data => $("#clusters").append(cluster("Frontend", data))))
  fetch('/api/clusters/2')
    .then(response => response.json())
    .then(data => data.forEach(
      data => $("#clusters").append(cluster("Compute Node", data))))
  $("#clusters").append(cluster("Meta Node", { host: { host: "127.0.0.1", port: "2333" } }))

  const eraseDownstreamIfChain = (dispatcher) => {
    // legacy dashboard doesn't support chain downstream, so we'd rather let the internal logic
    // think there's no downstream.

    // HACK: for chain nodes, their dispatcher id is the same as downstream actor id. We can leverage
    // this property to find such chain actors.
    if (dispatcher.type == "NO_SHUFFLE" && dispatcher.dispatcherId == dispatcher.downstreamActorId[0]) {
      return []
    } else {
      return dispatcher.downstreamActorId || []
    }
  }

  const toV1Actor = (actor) => {
    if (actor.dispatcher) {
      return ({
        ...actor,
        dispatcher: actor.dispatcher[0],
        downstreamActorId: eraseDownstreamIfChain(actor.dispatcher[0])
      })
    } else {
      return ({
        ...actor,
        dispatcher: { type: "BROADCAST", dispatcherId: "0", downstreamActorId: [] },
        downstreamActorId: []
      })
    }

  }

  const loadActors = () => {
    fetch('/api/actors')
      .then(response => response.json())
      .then(data => {
        const allActors = []
        data.forEach(data => {
          // bind node info with actor
          const nodeActors = data.actors.map(actor => ({ node: data.node, ...toV1Actor(actor) }))
          allActors.push(...nodeActors)
        })
        const processedData = { actors: allActors }
        $("#actors").empty()
        $("#actors").append(actors(processedData, 0))
        layoutStreamGraphs(processedData, 0)
      })
  }

  const handleMvOnMvClick = (cb) => {
    resolveMvOnMv = cb.checked
    loadActors()
  }

  const mvSelectOption = (id, name) =>
    `<option value="${id}">${name}</option>`

  const loadMvOptions = () => {
    $("#mvSelect").empty()
    $("#mvSelect").append(mvSelectOption("", "All"))
    currentMvs.clear()
    fetch('/api/materialized_views')
      .then(response => response.json())
      .then(data => data.forEach(
        (data) => {
          if (showAssociateMv || !data.hasOwnProperty('associatedSourceId')) {
            currentMvs.add(data.id)
            $("#mvSelect").append(mvSelectOption(data.id, data.name))
          }
        }))
  }

  const loadFragments = () => {
    $("#mvFragments").empty()
    fetch('/api/fragments')
      .then(response => response.json())
      .then(data => data.forEach(
        (data) => {
          if ((currentMv === "" && currentMvs.has(data[0])) || data[0].toString() === currentMv) {
            let mvActorData = { actors: data[1].map(toV1Actor) };
            $("#mvFragments").append(mvActors(mvActorData, data[0]))
            layoutStreamGraphs(mvActorData, data[0])
          }
        }));
  }

  const handleMvSelectClick = (select) => {
    currentMv = select.value
    loadFragments()
  }

  const handleShowAssociateMvClick = (cb) => {
    showAssociateMv = cb.checked
    currentMv = ""
    loadMvOptions()
    loadFragments()
  }

  loadActors()
  loadMvOptions()
  loadFragments()

</script>

</html>
